#! /usr/bin/env python2.5

# Copyright (c) 2010 Patrick Dubroy <pdubroy@gmail.com>
#
# This program is free software; you can redistribute it and/or modify it 
# under the terms of the GNU General Public License version 2 as published 
# by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful, but WITHOUT 
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 
# FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for 
# more details.

from __future__ import with_statement

import inspect
import keyword
import logging
import optparse
import os
import pygments
import pygments.lexers
import re
import shutil
import StringIO
import subprocess
import sys
import token
import tokenize
import traceback

USING_PYSIDE = int(os.environ.get("USING_PYSIDE", "0"))

if USING_PYSIDE:
	from PySide.QtCore import *
	from PySide.QtGui import *
	pyqtSignal = Signal
else:
	from PyQt4.QtCore import *
	from PyQt4.QtGui import *

import fileindex
import pipe

MAC_OS = sys.platform.startswith("darwin")

logging.basicConfig(
	level=logging.DEBUG,
	format="[%(levelname)s] %(message)s")

def rel(relpath):
	"""Given a path relative to this script, return the absolute path."""
	return os.path.join(os.path.dirname(__file__), relpath)

# Slurp up the stylesheet
with open(rel("style.css")) as f:
	stylesheet = f.read()

def pyqt_guarded(f):
	"""A decorator to prevent unhandled exceptions to be thrown outside of
	Python code. Should be used for any methods that are called directly
	from PyQt."""
	def wrapper(*args):
		try:
			return f(*args)
		except Exception, e:
			sys.stderr.write("Unhandled exception in wrapper around %s\n" % f)
			traceback.print_exc()
	return wrapper

# A decorator to be used for Python methods which override a Qt method.
pyqt_override = pyqt_guarded

def safe_connect(signal, slot):
	"""Connects a PyQt signal to a slot (a Python callable), while ensuring
	that no unhandled exceptions are raised in the slot.
	"""
	signal.connect(pyqt_guarded(slot))
	
def keyEventMatches(event, key, modifier="No"):
	"""Helper for handling Qt key events. 'key' is a string representing
	the key to look for, and 'modifier' is one of "Shift", "Control", etc.
	"""
	qt_key = getattr(Qt, "Key_" + key)
	modcode = getattr(Qt, modifier + "Modifier")
	return (event.key() == qt_key and event.modifiers() == modcode)

def toPyObject(obj):
	return obj if USING_PYSIDE else obj.toPyObject()

def toByteArray(obj):
	return obj if USING_PYSIDE else obj.toByteArray()

class KeyFilter(QObject):

	def __init__(self, win, tab, *args):
		QObject.__init__(self, *args)

		self._handlers = {}
		
		shortcuts = [
			("Control-T", win.new_tab),
			("Control-O", win.open_file),
			("Control-S", tab.save),
			("Control-W", win.close_tab),
			("Control-F", win.find_anything),
			("Control-L", tab.gotoLine),
			("Control+Shift-Left", win.prev_tab),
			("Control+Shift-Right", win.next_tab),
			("Control+Shift-F", win.find_in_project),
		]
		
		# Map from our simplified shortcut representation to an internal one,
		# which will make it easier to look up handlers for Qt events
		for shortcut, handler in shortcuts:
			mods, key = shortcut.split("-")
			modcode = Qt.NoModifier
			for m in mods.split("+"):
				modcode |= getattr(Qt, m + "Modifier")

			# When an arrow key is pressed, Qt.KeypadModifier is also set.
			# See http://doc.qt.nokia.com/4.7/qt.html#KeyboardModifier-enum
			# TODO: Is this OS X only?
			if key in ["Left", "Right", "Up", "Down"]:
				modcode |= Qt.KeypadModifier

			# In order to use this as a key in a dict, we need to convert
			# it from the Qt.KeyModifiers wrapper type to an int
			modcode = int(modcode)

			keycode = getattr(Qt, "Key_" + key)
			self._handlers[(keycode, modcode)] = handler

	@pyqt_override
	def eventFilter(self, obj, event):
		if event.type() == QEvent.KeyPress:
			lookupkey = (event.key(), int(event.modifiers()))
			handler = self._handlers.get(lookupkey, None)
			if handler:
				handler()
				return True # Swallow the event
		return False
		
class ImageButton(QPushButton):
	def __init__(self, name, width, height):
		QPushButton.__init__(self)
		self.setStyleSheet("""
			QPushButton {
				background-image: url(%s) no-repeat;
				background-repeat: no repeat;
				background-position: center;
				min-width: %dpx;
				min-height: %dpx;
				border: 0;
			}
			QPushButton:pressed {
				background-image: url(%s);
			}
		""" % (rel("graphics/%s.png" % name), width, height, rel("graphics/%s_pressed.png" % name)))
		self.setFlat(True)
		self.setFixedSize(width, height)


class PygmentsHighlighter(QSyntaxHighlighter):

	HIGHLIGHTED = 0
	NEEDS_REHIGHLIGHT = 1

	def __init__(self, textDocument):
		QSyntaxHighlighter.__init__(self, textDocument)
		
		self.doc = textDocument
		self.lexer = None
		
		# TODO: We should use the Pygments style format here
		self.formats = {
			"Keyword": "#33bbff",
			"Operator": "#33bbff",
			"Name.Class": "#ff8e4b",
			"Name.Decorator": "#ff8e4b",
			"Name.Function": "#ff8e4b",
			"Literal.String": "#009900",
			"Comment": "#0065ff",
			"Comment.Multi": "#00ff00"
		}
		for k, v in self.formats.iteritems():
			fmt = QTextCharFormat()
			fmt.setForeground(QColor(v))
			self.formats[k] = fmt
		
	def guess_mode(self):
		"""Attempt to guess the mode for the file based on its contents"""
	
		text = self.doc.toPlainText()
		try:
			self.lexer = pygments.lexers.guess_lexer(text)
		except pygments.util.ClassNotFound:
			pass
				
	def set_mode(self, filename):
		"""Set the syntax highlighting mode based on the filename"""

		try:
			self.lexer = pygments.lexers.get_lexer_for_filename(filename)
		except pygments.util.ClassNotFound:
			pass
			
	def getformat(self, tokentype):
		parts = str(tokentype).split(".")
		parts.pop(0) # Get rid of the "Token." part
		
		# Search for the most specific match.
		# i.e. "Comment.Multiline" before "Comment"
		fmt = None
		while parts and fmt is None:
			fmt = self.formats.get(".".join(parts), None)
			parts.pop(-1)
		return fmt

	@pyqt_override
	def rehighlightBlock(self, block):
		if block.userState() == self.NEEDS_REHIGHLIGHT:
			QSyntaxHighlighter.rehighlightBlock(self, block)
			
	def getstack(self, block):
		userdata = block.userData()
		return userdata.stack if userdata else None
		
	@pyqt_override
	def highlightBlock(self, text):
		"""Highlight a line of text.
		
		Even when highlighting the entire document (e.g. on startup),
		highlighting is done a line at a time. This makes it somewhat
		difficult to handle multiline elements, such as comments in
		C++ and Java, or triple-quoted strings in Python.
		
		The trick is to pass in a stack where the lexer state is
		kept, and after lexing the line, if the list contains more
		than one element, then we know it ended in the middle of
		scanning a multiline lexeme.
		"""
		if not self.lexer:
			return

		# Pygments needs the newline for correct parsing
		# TODO: Fix Pygments language specs to check for \n or $
		text += "\n"

		currentBlock = self.currentBlock()

		# If the previous line ended on a multi-line lexeme, copy
		# its stack so we can resume lexing in the same state
		prevstack = self.getstack(currentBlock.previous())
		stack = list(prevstack if prevstack else ("root",))

		# We can only pass the stack in if it's an instance of RegexLexer
		# TODO: Fix this hack!
		if isinstance(self.lexer, pygments.lexer.RegexLexer):
			tokenstream = self.lexer.get_tokens_unprocessed(text, stack=stack)
		else:
			tokenstream = self.lexer.get_tokens_unprocessed(text)
		for i, tokentype, value in tokenstream:
			if value != ".":
				fmt = self.getformat(tokentype)
				if fmt:
					self.setFormat(i, len(value), fmt)
					
		oldstack = self.getstack(currentBlock)
		
		if stack != oldstack:
			block = currentBlock.next()
			if block.isValid():
				# Schedule a rehighlight without blocking the UI thread
				block.setUserState(self.NEEDS_REHIGHLIGHT)
				Kurt.async_exec(self.rehighlightBlock, block)

			# Save the stack
			userData = currentBlock.userData() or QTextBlockUserData()
			userData.stack = stack
			self.setCurrentBlockUserData(userData)

		currentBlock.setUserState(self.HIGHLIGHTED)


class FindAnythingWindow(QWidget):

	def __init__(self):
		window_flags = Qt.Tool | Qt.WindowStaysOnTopHint		
		super(FindAnythingWindow, self).__init__(None, window_flags)

	def show(self):
		super(FindAnythingWindow, self).show()
		self.raise_()


class FindBar(QWidget):

	closed = pyqtSignal()

	FOUND_STYLE = "QLineEdit { background-color: white; color: black; }"
	NOT_FOUND_STYLE = "QLineEdit { background-color: #FF6666; color: white; }"

	def __init__(self, textEdit):
		QWidget.__init__(self)

		self.textEdit = textEdit
		layout = QHBoxLayout()
		layout.setContentsMargins(4, 4, 4, 4)
		layout.setSpacing(4)
		self.setLayout(layout)
		
		# Make the find bar content right-aligned.
		layout.addStretch(1)
		
		label = QLabel(text="Find:")
		layout.addWidget(label)

		self.lineEdit = QLineEdit()
		self.lineEdit.setMinimumWidth(150)
		layout.addWidget(self.lineEdit)
		
		closeButton = ImageButton("close", 16, 16)
		closeButton.clicked.connect(self.closed)
		closeButton.setCursor(Qt.ArrowCursor)
		layout.addWidget(closeButton)

		self.lineEdit.installEventFilter(self)
		safe_connect(self.lineEdit.textEdited, self._findText)
		self.setFocusProxy(self.lineEdit)
		
		self.setObjectName("findBar") # For styling purposes
		self.setStyleSheet("""
			QLabel { font-size: 10pt; padding-top: 2px; }
			QLineEdit { font-size: 10pt; border: 1px solid DarkGray; }
		""")
		
		self._originalCursor = None
	
	@pyqt_override
	def eventFilter(self, obj, event):
		if (event.type() == QEvent.KeyPress
		and event.modifiers() == Qt.NoModifier):
			key = event.key()
			if key == Qt.Key_Escape:
				self._clearSelection()
				self.closed.emit()
				return True
			elif key == Qt.Key_Return or key == Qt.Key_Down:
				# Go to the next match
				self._findText(self.lineEdit.text(), False)
				return True
			elif key == Qt.Key_Up:
				# Go to the previous match
				self._findText(self.lineEdit.text(), False, False)
				return True
		return QObject.eventFilter(self, obj, event)

	def _clearSelection(self):
		cursor = self.textEdit.textCursor()
		cursor.clearSelection()
		self.textEdit.setTextCursor(cursor)
		
	def _setBackground(self, found):
		style = self.FOUND_STYLE if found else self.NOT_FOUND_STYLE
		self.lineEdit.setStyleSheet(style)
		
	def _findText(self, text, includeSelection=True, forwards=True):
		if len(text) == 0:
			self._setBackground(found=True)
			self._clearSelection()
			if self._originalCursor:
				self.textEdit.setTextCursor(self._originalCursor)
				self._originalCursor = None
		else:
			cursor = self.textEdit.textCursor()
			if includeSelection or not forwards:
				start = cursor.selectionStart()
			else:
				start = cursor.selectionEnd()
			self._find_from_cursor(text, start, forwards)
		
	def _find_from_cursor(self, text, cursor, forwards=True):
			flags = QTextDocument.FindFlags()
			if not forwards:
				flags = QTextDocument.FindBackward
			cursor = self.textEdit.document().find(text, cursor, flags)

			# If the text wasn't found, and this is the same search
			# that was just performed, then try wrapping the search.
			if cursor.isNull() and (text, forwards, False) == self._last_find:
				wrapped_cursor = QTextCursor(self.textEdit.document())
				self._find_from_cursor(text, wrapped_cursor, forwards)
			else:
				found = (not cursor.isNull())
				if found:
					self.textEdit.setTextCursor(cursor)	
				self._setBackground(found=found)
				self._last_find = (text, forwards, found)
			
	def focus(self, text=None):
		if text:
			self.lineEdit.setText(text)
		self.lineEdit.selectAll()
		self.setFocus()

	@pyqt_override
	def focusEvent(self, event):
		# TODO: This should be handled in the Editor, not here.
		self._originalCursor = self.textEdit.textCursor()


class KTextEdit(QPlainTextEdit):
	
	def __init__(self, parent=None):
		QPlainTextEdit.__init__(self, parent)
		self.setObjectName("editor") # For styling purposes
		self.setStyleSheet(stylesheet)
		self.setCursorWidth(1)

		# Allow the widget to take focus with a click or a keyboard
		# event, but not a mouse wheel event.		
		self.setFocusPolicy(Qt.StrongFocus)

		self.set_soft_tabs(False)
		
		# Stuff for highlighting the current line.
		self._extra_selection = sel = QTextEdit.ExtraSelection()
		sel.format.setBackground(QColor(65, 65, 65))
		sel.format.setProperty(QTextFormat.FullWidthSelection, True)
		safe_connect(self.cursorPositionChanged, self._highlight_current_line)

	def set_soft_tabs(self, use_soft_tabs, width=4):
		self.soft_tabs = use_soft_tabs
		self.tab_width = width
		if use_soft_tabs:
			self._indent_string = " " * width
		else:
			self._indent_string = "\t"

	def _highlight_current_line(self):
		self._extra_selection.cursor = self.textCursor()
		self.setExtraSelections([self._extra_selection])

	def _unindentAt(self, cursor):
		# Helper for un-indenting a line.
		if self._moveOverIndent(cursor, mode=QTextCursor.KeepAnchor):
			cursor.deleteChar()
	
	def _moveOverIndent(
			self, cursor, backwards=False, mode=QTextCursor.MoveAnchor):
		"""Move the given cursor over one level of indentation."""
		doc = self.document()

		# Perform a character-by-character comparison of the indent string
		# and the text at the cursor location.
		pos = cursor.position()
		if backwards:
			pos -= 1
		for c in self._indent_string:
			if doc.characterAt(pos) != QChar(c):
				return False
			pos += -1 if backwards else 1

		# Only move the cursor on a full match.
		direction = QTextCursor.Left if backwards else QTextCursor.Right
		cursor.movePosition(direction, mode, len(self._indent_string))
		return True

	@pyqt_override
	def keyPressEvent(self, event):
		# Handle tabs specially: if there's a selection spanning multiple 
		# lines, hitting tab indents all the spanned lines.

		key = event.key()
		modifiers = event.modifiers()

		indent = keyEventMatches(event, "Tab")
		unindent = keyEventMatches(event, "Backtab", "Shift")

		if indent or unindent:
			cursor = self.textCursor()
			# Check if we have multiple lines selected.
			# (Don't use cursor.selectedText(), it uses \u2029 instead of \n.)
			if cursor.selection().toPlainText().contains("\n"):
				# Append a tab at the beginning of every line in the selection,
				# except the last line if the cursor is at position 0.
				ins_cursor = QTextCursor(cursor)
				ins_cursor.beginEditBlock()
				ins_cursor.setPosition(cursor.selectionStart())
				ins_cursor.movePosition(QTextCursor.StartOfLine)
				while ins_cursor.position() < cursor.position():
					if indent:
						ins_cursor.insertText(self._indent_string)
					else:
						self._unindentAt(ins_cursor)
					# Move to the beginning of the next line
					ins_cursor.movePosition(QTextCursor.NextBlock)
				ins_cursor.endEditBlock()
			else:
				# Insert the appropriate indent.
				self.textCursor().insertText(self._indent_string)
			return

		if keyEventMatches(event, "Return"):
			# Find the amount of leading indentation to the left of the cursor
			# The same indentation will be added to the next line
			
			cursor = self.textCursor()
			c = self.textCursor()
			c.movePosition(QTextCursor.StartOfLine)
			
			level = 0
			while self._moveOverIndent(c) and c <= cursor:
				level += 1
			
			# Insert a line break and match the indentation level
			self.textCursor().insertText("\n" + level * self._indent_string)
			return

		# When using soft tabs, certain keys should jump to the next
		# indentation stop, rather than consuming a single character.
		
		is_left = key == Qt.Key_Left and modifiers & Qt.KeypadModifier
		is_right = key == Qt.Key_Right and modifiers & Qt.KeypadModifier
		
		if is_left or is_right:
			# If there are modifiers other than Shift, then there is no
			# special handling required.
			if not modifiers & ~(Qt.KeypadModifier | Qt.ShiftModifier):
				mode = QTextCursor.MoveAnchor
				if modifiers & Qt.ShiftModifier:
					mode = QTextCursor.KeepAnchor
		
				cursor = self.textCursor()
				if self._moveOverIndent(cursor, backwards=is_left, mode=mode):
					self.setTextCursor(cursor)
					return

		# Execute the default handler.
		QPlainTextEdit.keyPressEvent(self, event)


class Animator(QObject):

	INTERVAL = 16 # Time in ms between animation steps
	
	def __init__(self):
		# The animation timer is used for animating the top bars.
		self._timer = QTimer()
		safe_connect(self._timer.timeout, self._animation_step)

	def _animation_step(self):
		self.value = self.value + self._increment
		done = False
		if not self._min < self.value < self._max:
			self._timer.stop()
			done = True
		self.value = max(self._min, min(self.value, self._max))
		self._callback(self.value)
		if done and self._end_callback is not None:
			self._end_callback()

	def animate(self, start_val, end_val, duration, callback, end_callback=None):
		self.start_value = start_val
		if not self._timer.isActive():
			self.value = start_val
		self.end_value = end_val
		self._min = min(start_val, end_val)
		self._max = max(start_val, end_val)
		self._callback = callback
		self._end_callback = end_callback

		# We don't need more than one frame per pixel of height.
		frames = max(1, duration / self.INTERVAL)		
		self._increment = (end_val - start_val) * 1. / frames
		
		if not self._timer.isActive():	
			self._timer.start(self.INTERVAL)

	def reverse(self, duration, callback=None, end_callback=None):
		if callback is None:
			callback = self._callback
		self.animate(self.end_value, self.start_value, duration,
			callback, end_callback)


class NonModalBanner(QWidget):

	DURATION = 100 # Duration of the animation in milliseconds.

	def __init__(self, child):
		QWidget.__init__(self)
		self.setContentsMargins(0, 0, 0, 0)

		self._animator = Animator()

		self._child = child
		child.setParent(self)

		self._old_width = 0

	def _set_height(self, height):
		self.setFixedHeight(height)
		self._child.move(0, height - self._full_height)

	@pyqt_override
	def resizeEvent(self, event):
		width = self.width()
		if width != self._old_width:
			self._old_width = width
			self._child.setFixedWidth(width)

	@pyqt_override
	def show(self):
		QWidget.show(self)

		# Determine the ideal size of the content.
		size = self._child.sizeHint()
		self._child.setFixedSize(self.width(), size.height())
		self._full_height = size.height()

		# Start the animation if necessary.
		if self.height() == self._full_height:
			return
		endval = self._full_height
		self._animator.animate(0, endval, self.DURATION, self._set_height)
		
	def _hide(self):
		QWidget.hide(self)

	@pyqt_override
	def hide(self):
		self._animator.reverse(self.DURATION, end_callback=self._hide)


class WarningBanner(NonModalBanner):

	def __init__(self, message):
		contents = QWidget()
		contents.setObjectName("warning-banner")
		contents.setStyleSheet(stylesheet)
		layout = QHBoxLayout()
		layout.setContentsMargins(8, 0, 8, 2)
		layout.setSpacing(8)
		contents.setLayout(layout)
		
		label = QLabel()
		label.setText(message)
		layout.addWidget(label)
		
		self._layout = layout
		
		NonModalBanner.__init__(self, contents)

	def _button_clicked(self, checked):
		self.hide()

	def add_button(self, text, click_handler):
		button = QPushButton(text)
		safe_connect(button.clicked, lambda checked: click_handler())
		safe_connect(button.clicked, self._button_clicked)
		self._layout.addWidget(button)


class Editor(QWidget):

	# Emitted when the title of the tab has changed
	titleChanged = pyqtSignal(str)
	
	# Emitted when the document modification state has changed
	modificationChanged = pyqtSignal(bool)

	def __init__(self, window, *args):
		QWidget.__init__(self, *args)
		
		self.window = window
		self.path = None # Path to the file that is open in this tab
		
		layout = QVBoxLayout()
		layout.setContentsMargins(0, 0, 0, 0)
		layout.setSpacing(0)
		self.setLayout(layout)

		self.textEdit = KTextEdit(self)
		doc = self.textEdit.document()
		doc.modificationChanged.connect(self.modificationChanged)
		safe_connect(doc.contentsChanged, self._contentsChanged)
		layout.addWidget(self.textEdit)

		self.setFocusProxy(self.textEdit)

		self.findBar = FindBar(self.textEdit)
		safe_connect(self.findBar.closed, self.hide_find_bar)
		self.find_bar_banner = NonModalBanner(self.findBar)

		layout.insertWidget(0, self.find_bar_banner)

		self.reload_banner = WarningBanner(
			"Whoops, this file changed on disk. Do you want to reload it?")
		self.reload_banner.add_button("Reload", self.reload)
		self.reload_banner.add_button("No", self._prevent_reload)
		layout.insertWidget(0, self.reload_banner)

		self.keyFilter = KeyFilter(window, self)
		self.textEdit.installEventFilter(self.keyFilter)
		
		self._save_timer = QTimer(self)		
		safe_connect(self._save_timer.timeout, self._saveTimeout)

		self.highlighter = PygmentsHighlighter(self.textEdit.document())
		safe_connect(self.titleChanged, self.updateMode)

	@pyqt_override
	def showEvent(self, event):
		# Set the tab width to 4 chars (assuming monospace font)
		# If we do this in the constructor, it's not calculated correctly
		fontMetrics = self.textEdit.fontMetrics()
		self.textEdit.setTabStopWidth(fontMetrics.width("0000"))

	def _contentsChanged(self):
		# When the contents of the document change, save the document if no
		# more changes are made after 500 milliseconds have elapsed

		# Autosave is disabled for now
		if False:
			self._save_timer.stop()
			self._save_timer.start(500)

	def _saveTimeout(self):
		if self.path:
			self.save()
			
	def getTitle(self):
		if self.path:
			return os.path.basename(self.path)
		return "New File"
		
	def isModified(self):
		return self.textEdit.document().isModified()
		
	def open_file(self, path):
		"""Open the file indicated by 'path' into this editor. 'path' may be
		an absolute path, or relative to the current working directory."""
		if os.path.exists(path):
			# TODO: Don't block the UI to read the entire file
			self.textEdit.setPlainText(open(path, "r").read())
			self.textEdit.document().setModified(False)
		self.path = os.path.abspath(path) # Always save as absolute
		self._last_modified = os.path.getmtime(self.path)
		self.titleChanged.emit(self.getTitle())

	def save(self):
		if not self.textEdit.document().isModified():
			return

		if self.path is None:
			self.path = str(QFileDialog.getSaveFileName(self.window))

			# If the user didn't select a file, abort the save operation
			if len(self.path) == 0:
				self.path = None
				return
				
			self.titleChanged.emit(self.getTitle())

		# Rename the original file, if it exists
		overwrite = os.path.exists(self.path)
		if overwrite:
			temp_path = self.path + "~"
			if os.path.exists(temp_path):
				os.remove(temp_path)
			os.rename(self.path, temp_path)			

		try:
			# Save the new contents
			with open(self.path, "w") as f:
				f.write(self.textEdit.document().toPlainText())

			if overwrite:
				os.remove(temp_path)
		except:
			if overwrite and os.path.exists(temp_path):
				os.remove(self.path)
				os.rename(temp_path, self.path)

		self.textEdit.document().setModified(False)
		self._last_modified = os.path.getmtime(self.path)

	def find_old(self):
		cursor = self.textEdit.textCursor()
		self.findBar.focus(cursor.selectedText())
		self.find_bar_banner.show()

	def find(self):
		pass
		
	def hide_find_bar(self):
		self.find_bar_banner.hide()
		self.textEdit.setFocus()
			
	def move_cursor_to_line(self, line_num):
		# Note: findBlockByLineNumber takes a 0-based index
		block = self.textEdit.document().findBlockByLineNumber(line_num - 1)
		self.textEdit.setTextCursor(QTextCursor(block))		

	def gotoLine(self):
		linecount = self.textEdit.document().lineCount()
		# Show the current line number as the start value.
		current_line = self.textEdit.textCursor().block().firstLineNumber()

		line_num, ok = QInputDialog.getInt(
			self, "Go to Line", "Line number:",
			value=current_line + 1, min=1, max=linecount)
		if ok:
			self.move_cursor_to_line(line_num)

	def updateMode(self, title):
		self.highlighter.set_mode(os.path.basename(self.path))
		self.highlighter.rehighlight()

	def gained_focus(self):
		if self.path and self._last_modified != os.path.getmtime(self.path):
			self.reload_banner.show()

	def reload(self):
		self.open_file(self.path)

	def _prevent_reload(self):
		self._last_modified = os.path.getmtime(self.path)

class FindResultsList(QTreeView):
	def __init__(self, win):
		super(FindResultsList, self).__init__()
		self.setSelectionMode(QAbstractItemView.SingleSelection)
		self.setHeaderHidden(True)
		self.setModel(QStandardItemModel())
		self._win = win
		
	def begin(self, pattern):
		self.model().clear()

		# The current model item that we are appending to.
		# It's a top-level node in the tree view, containing the filename,
		# and we append the matching lines as children of it.
		self._node = None

		self.expandAll()
		self.setFocus()
		self._pattern = pattern
		
		# Show the dock (assumes that this is the only widget in the dock)
		self._win.dock.show()

	def process_line(self, line):
		"""Process a line of input from the find results."""
		match = re.match("^(.*):(\d+):(.*)", str(line))
		if match is None:
			logging.warning("Skipping result '%s'" % each)
			return
		filename, line_num, contents = match.groups()
		if self._node is None or self._node.text() != filename:
			self._node = QStandardItem(filename)
			self.model().appendRow(self._node)
			self.expand(self._node.index())
		row = QStandardItem("%s: %s" % (line_num, contents.strip()))
		row.setData(filename)
		self._node.appendRow(row)
		
	def selectionChanged(self, selected, deselected):
		QTreeView.selectionChanged(self, selected, deselected)

		model = self.model()

		# Sentinel value returned when a QModelIndex has no parent		
		NO_PARENT = QModelIndex()
		
		# This is clunky, but it seems that this is how you do
		# things in the Qt model/view framework.

		index = self.selectedIndexes()[0]
		parent_index = index.parent()
		line_num = None
		if parent_index == NO_PARENT:
			filename = model.item(index.row()).text()
		else:
			parent = model.item(parent_index.row())
			filename = parent.text()
			child_text = parent.child(index.row()).text()
			# Parse the line number directly out of the child text
			line_num = int(child_text.split(":", 1)[0])

		editor = self._win.open_file(str(filename), focus_existing_tab=True)
		if line_num:
			editor.move_cursor_to_line(int(line_num))		
			editor.textEdit.find(self._pattern)
		self.setFocus()

class MainWindow(QMainWindow):

	# Emitted when the window is moved or resized
	geometryChanged = pyqtSignal()
	
	# Emitted when tabs are opened or closed, etc.
	contentsChanged = pyqtSignal()
	
	# Emitted when the window is closed (by user action)
	windowClosed = pyqtSignal(bool)

	def __init__(self, basedir):
		QMainWindow.__init__(self)

		self.fileindex = fileindex.Index()
		if basedir:
			self.fileindex.add_all(basedir)

		self.basedir = basedir

		self.closed_cleanly = True

		# Create the tab widget
		self.tabWidget = QTabWidget()
		self.tabWidget.setMovable(True)
		self.tabWidget.setDocumentMode(True)
		safe_connect(self.tabWidget.currentChanged, self.tabSwitched)
		
		# Create the widget for "Find in Project" results
		self.find_results = FindResultsList(self)
		self.find_results.hide()
		self.find_results.setObjectName("find-results") # For styling purposes
		self.find_results.setStyleSheet(stylesheet)
		self.find_results.setCursor(Qt.ArrowCursor)

		# Create the "Find Anything" widget.
		self.find_anything_window = FindAnythingWindow()
		
		# Create the dock which can appear on the bottom of the window
		self.setCentralWidget(self.tabWidget)
		self.dock = QDockWidget()
		self.addDockWidget(Qt.BottomDockWidgetArea, self.dock)

		# For now, only the find results can appear in the dock
		self.dock.setWidget(self.find_results)

		# Customize the dock title bar
		titlebar = QWidget()
		titlebar.setCursor(Qt.ArrowCursor)

		layout = QHBoxLayout()
		layout.setContentsMargins(4, 2, 4, 2)
		layout.setSpacing(2)		
		titlebar.setLayout(layout)
		
		label = QLabel("Find in Project")
		font = QFont()
		font.setPointSize(10)
		label.setFont(font)
		layout.addWidget(label)

		closeButton = ImageButton("close", 16, 16)
		safe_connect(closeButton.clicked, self.close_dock)
		closeButton.setCursor(Qt.ArrowCursor)
		layout.addWidget(closeButton)
		
		self.dock.setTitleBarWidget(titlebar)

		# Make the dock easier a bit easier to resize
		self.dock.setCursor(Qt.SplitVCursor)

		self.dock.hide()
		
	def close_dock(self, event):
		self.dock.hide()
		
	@pyqt_override	
	def showEvent(self, event):
		# If no tabs exist yet, create a default one
		if self.tabWidget.count() == 0:
			self.new_tab()
		QMainWindow.showEvent(self, event)

	@pyqt_override
	def moveEvent(self, event):
		self.geometryChanged.emit()
		QMainWindow.moveEvent(self, event)
	
	@pyqt_override
	def resizeEvent(self, event):
		self.geometryChanged.emit()
		QMainWindow.resizeEvent(self, event)

	@pyqt_override
	def closeEvent(self, event):
		self.windowClosed.emit(self.closed_cleanly)
		event.accept()
	
	@pyqt_override
	def dragEnterEvent(self, event):
		# Only accept the drag and drop if it's files
		# The TextEdit widget will accept drops of text itself
		mimeData = event.mimeData()
		if mimeData.hasUrls():
			for url in mimeData.urls():
				if url.scheme() != "file":
					return
		event.acceptProposedAction()

	@pyqt_override
	def dragMoveEvent(self, event):
		event.acceptProposedAction()

	@pyqt_override
	def dropEvent(self, event):
		# We can assume once we get here it's only file: URLs
		for url in event.mimeData().urls():
			# TODO: Do we need to check that the file exists?
			self.new_tab(url.toLocalFile())
		event.acceptProposedAction()
		
	def updateWindowTitle(self):
		editor = self.tabWidget.currentWidget()
		filename = os.path.basename(editor.path) if editor.path else "New File"
		self.setWindowTitle(filename + " - Kurt")

	def tabSwitched(self, index):
		# Handle the case when the last tab is closed
		if index >= 0:
			self.updateWindowTitle()

	def new_tab(self, filename=None, contents=None):
		"""Open a new editor tab. If filename is specified, it will be loaded
		into the tab. Otherwise, if contents (a string) is specified, the
		editor text will be set to that.

		Returns the tab's Editor instance.
		"""
		editor = Editor(self)
		safe_connect(editor.modificationChanged, self.tabModificationChanged)
		safe_connect(editor.titleChanged, self.tabTitleChanged)

		index = self.tabWidget.addTab(editor, editor.getTitle())

		if filename and os.path.exists(filename):
			editor.open_file(str(filename))
		elif contents:
			editor.textEdit.setText(contents)
		self.tabWidget.setCurrentIndex(index) # Switch to the new tab
		self.contentsChanged.emit()
		editor.setFocus()
		return editor

	def set_tab_title(self, editor, title):
		index = self.tabWidget.indexOf(editor)
		self.tabWidget.setTabText(index, title)
		
	def close_tab(self):
		self.tabWidget.removeTab(self.tabWidget.currentIndex())
		if self.tabWidget.count() == 0:
			self.close()
		else:
			self.tabWidget.currentWidget().setFocus()
			self.contentsChanged.emit()
			
	def prev_tab(self):
		t = self.tabWidget
		t.setCurrentIndex((t.currentIndex() - 1) % t.count())
		
	def next_tab(self):
		t = self.tabWidget
		t.setCurrentIndex((t.currentIndex() + 1) % t.count())
		
	def tabTitleChanged(self, title):
		tab = self.sender()
		self.set_tab_title(tab, title)
		if tab == self.tabWidget.currentWidget():
			self.updateWindowTitle()

		# Assume that the open files have changed
		self.contentsChanged.emit()
		
	def tabModificationChanged(self, modified):
		# Indicate whether or not a tab is modified by the color of its label
		index = self.tabWidget.indexOf(self.sender())
		color = Qt.darkGray if modified else Qt.black
		self.tabWidget.tabBar().setTabTextColor(index, color)
		
	def open_file(self, filename=None, focus_existing_tab=False):
		if filename is None:
			# Prompt the user for the filename
			filename = str(QFileDialog.getOpenFileName(self))
		elif focus_existing_tab:
			# Look for a tab that has this file open, and focus it.
			tabs = self.tabWidget
			filename = os.path.abspath(filename)
			for i in xrange(tabs.count()):
				editor = tabs.widget(i)
				if editor.path == filename:
					# Focus the tab.
					tabs.setCurrentIndex(i)					
					return editor

		if len(filename) > 0:
			return self.new_tab(filename)
			
	def get_editors(self):
		"""Return a list of the editors open in the window."""
		tabs = self.tabWidget
		return [tabs.widget(i) for i in xrange(tabs.count())]
		
	def find_in_project(self):
		"""Show the Find in Project dialog."""
		print "index size:", self.fileindex.size()
		command, ok = QInputDialog.getText(self, "Find in Project", "")
		if not ok:
			return
		command = str(command) # Convert from QString
			
		# HACK - Assume everything after the first token is the pattern
		pattern = command.split(None, 1)[1]
		self.find_results.begin(pattern)

		# Start up a subprocess to perform the find. Output from the process
		# is read on a separate thread and the UI updated asynchronously.
		p = subprocess.Popen(
			command, cwd=self.basedir, stdout=subprocess.PIPE, shell=True)
		reader = pipe.LineReader(p.stdout)
		reader.nextLine.connect(self.find_results.process_line)
		reader.read_all(spawn_thread=True)

	def find_anything(self):
		"""Show the find anything dialog."""
		self.find_anything_window.show()


class Kurt(QApplication):

	_async_event_type = QEvent.Type(QEvent.registerEventType())

	def __init__(self, settings, listener=None, project_dir=None):
		QApplication.__init__(self, sys.argv)
		self.fileindex = None

		if project_dir is not None:
			assert os.path.isdir(project_dir)

		self.settings = settings

		# Figure out if the last session closed cleanly
		self.closed_cleanly = True
		if self.settings.contains("session/closed-cleanly"):
			self.closed_cleanly = toPyObject(
				self.settings.value("session/closed-cleanly"))
		self.settings.setValue("session/closed-cleanly", False)

		self._listener = listener
		if listener:
			safe_connect(self._listener.remoteOpenRequest, 	
				self._openFromExternalProcess)

		safe_connect(self.lastWindowClosed, self.shutDown)
		safe_connect(self.focusChanged, self._handle_focus_changed)

		self.win = MainWindow(project_dir)
		safe_connect(self.win.geometryChanged, self.geometryChanged)
		safe_connect(self.win.contentsChanged, self.saveTabs)
		safe_connect(self.win.windowClosed, self.windowClosed)
		
		self.restoring = False

	@pyqt_override
	def event(self, e):
		if e.type() == self._async_event_type:
			e.func(*e.args)
			return True
		return False

	@classmethod
	def async_exec(cls, func, *args):
		"""Post a function to be called from the main event loop."""
	
		app = cls.instance()
		evt = QEvent(cls._async_event_type)
		evt.func = func
		evt.args = args
		cls.postEvent(app, evt)

	def _handle_focus_changed(self, old, now):
		if isinstance(now, KTextEdit):
			now.parent().gained_focus()

	def _openFromExternalProcess(self, filename):
		if len(filename) > 0:
			self.win.new_tab(filename)
		self.win.raise_()
	
	def geometryChanged(self):
		"""Saves the width, height, and position of the window."""
		if not self.restoring:
			self.settings.setValue("session/geometry", self.win.saveGeometry())
			
	def windowClosed(self, closed_cleanly):
		self.closed_cleanly = closed_cleanly
		self.settings.setValue("session/closed-cleanly", closed_cleanly)
			
	def saveTabs(self):
		"""Saves a list of all the files that are currently open in tabs."""
		if not self.restoring:
			open_files = [x.path for x in self.win.get_editors() if x.path]
			paths = os.pathsep.join(open_files)
			self.settings.setValue("session/tabs", paths)

	def restoreTabs(self):
		self.restoring = True
		paths = str(toPyObject(self.settings.value("session/tabs")))
		for path in paths.split(os.pathsep):
			self.win.open_file(path)
		self.restoring = False
		
	def restore_geometry(self):
		# Try to restore the previous settings
		if self.settings.contains("session/geometry"):
			self.win.restoreGeometry(
				toByteArray(self.settings.value("session/geometry")))
		
	def restore_session(self):
		self.restore_geometry()
		self.restoreTabs()
		
	def start(self, files=[], contents=[]):
		if self._listener:
			self._listener.start()
		
		if not self.closed_cleanly or len(files) == 0:
			self.restore_session()
		else:
			self.restore_geometry()
	
		for each in files:
			self.win.new_tab(filename=each)
		for each in contents:
			self.win.new_tab(contents=each)

		self.win.show()
		self.win.raise_()
		return self.exec_()
		
	def shutDown(self):
		if self._listener:
			self._listener.shutdown()
		rc = 0 if self.closed_cleanly else 1
		self.exit(rc)
	
def main():
	# On Mac OS, there is an extra arg with the process serial number
	# when we are launched from Finder. Just ignore it.
	if MAC_OS and len(sys.argv) > 1 and sys.argv[1].startswith("-psn_"):
		sys.argv.pop(1)
		
	settings = QSettings(
		QSettings.IniFormat, 
		QSettings.UserScope, 
		"dubroy.com", 
		"kurt")
	configDirName = os.path.dirname(str(settings.fileName()))
	if not os.path.exists(configDirName):
		os.makedirs(configDirName)
	if not os.path.isdir(configDirName):
		logging.error("Unable to create config directory '%s'" % configDirName)

	parser = optparse.OptionParser()
	parser.add_option("-w", "--wait", action="store_true",
		help="Open a new Kurt instance and wait until it exists")

	options, args = parser.parse_args()

	project_dir = None
	if len(args) == 1 and os.path.isdir(args[0]):
		project_dir = args.pop(0)

	if options.wait or project_dir:
		Kurt(settings, project_dir=project_dir).start(args)
		return

	# Try to create a Listener. If successful, it means no other process is
	# running. If it fails, then connect to the running process and tell
	# it to open up the given file(s).
	listener = pipe.Listener(configDirName)
	if listener:
		logging.debug("Starting new instance")
		Kurt(settings, listener).start(args)
	else:
		logging.debug("Connecting to existing instance")
		conn = pipe.Client(configDirName)
		logging.debug("Connected")

		for filename in args:
			conn.send("open " + os.path.abspath(filename))

		if len(args) == 0:
			conn.send("raise")

		conn.close()

if __name__ == "__main__":
	main()	
